Creating trellis chart
The New York Times published an investigation into the practice of buying fake Twitter accounts, exploring the phenomenon through the lens of Devumi — a single company that specialized in selling realistic-looking followers — bots masquerading behind the faces of real people, whose names and photographs had been scraped from Twitter.
In 2018 twitter began purging its platform of tens of millions of fake accounts.This event was greatly known as Great Twitter Bot Purge of 2018.
Let say we want to see a mix of Twitter accounts, some which had been caught with fake followers by the Times investigation. To visualize this in the most efficient manner the best way to do so is by a Trellis Layout.
You can very easily create a Trellis Layout through Muze API.
Lets do so step by step.
Create a DataModel instance using the given data
const formattedData = await DataModel.loadData(data, schema);
let rootData = new DataModel(formattedData);
Create a Muze environment
const { muze } = viz;
Create a canvas for a user and set the general properties
We need to create a chart for individual users to illustrate the trends. To do so we need to create that many number of canvas. For simplicity, lets create a canvas for a single user and then we can extend.
// Create a mount point for the canvas.
const canvasMountPoint = document.createElement("div");
canvasMountPoint.className = "chart-div";
canvasMountPoint.id = `chart${i + 1}`;
canvasMountPoint.style.overflow = "auto";
// create a canvas and set general properties
const user = user[0]; // take any user from the user list
const canvas = muze.canvas();
canvas.rows([[], ["followers"]]); // set followers in Y-Axis (Right Side)
canvas.columns(["time"]); // set X-Axis to time duration
// filter rawdata based on the user
canvas.data(
rootData.select({
field: "user",
value: user,
operator: DataModel.ComparisonOperators.EQUAL,
}),
);
canvas.width(250);
canvas.height(200);
Lets add a point to show the number of followers at the end of the purge
To do so we add the following code using the .layers() API
canvas.layers([
{
mark: "line",
},
{
mark: "point",
},
]);
Since we only need to show the last point we will filter out all the other value. This can be done for each layer as follows:
.layers([
{
mark: 'line',
},
{
mark: 'point',
source: ds => {
const dataLength = ds.getData().data.length;
return ds.select({
field: 'time',
value: dm.getField('time').domain()[1],
operator: DataModel.ComparisonOperators.EQUAL
});
}
}
])
NOTE: Here ds variable is a DataStore instance created by Muze internally. It is a wrapper over DataModel with some additional utility functions.
Lets add a text showing the number of followers at the end of the purge
To do this we need to add another text layer for the the last point as:
{
mark: 'text',
source: ds => {
const dataLength = ds.getData().data.length;
return ds.select({
field: 'time',
value: ds.getField('time').domain()[1],
operator: DataModel.ComparisonOperators.EQUAL
});
},
encoding: {
text: {
field: 'followers',
},
}
}
This encoding specifies from which field data needs to be taken.
We see that we need to so some optimisation in 2 places:
- Data is being filtered twice to take the last point ie from point and text.
- The text is overlapping with point giving an unpleasant look.
To solve the first point, Muze provides an API transform that can be used to create different DataModel instance from the root DataModel instance which in turn can be used by different layers. So we move the common code to .transform()
canvas.transform({
lastPoint: (ds) => {
const dataLength = ds.getData().data.length;
return ds.select({
field: "time",
value: ds.getField("time").domain()[1],
operator: DataModel.ComparisonOperators.EQUAL,
});
},
});
We named this DataModel Instance lastpoint to used later. Now we can change out code as below:
.layers([{
mark: 'line',
}, {
mark: 'point',
source: 'lastPoint' // Pointing to the new DataModel created
}, {
mark: 'text',
encoding: {
text: {
field: 'followers',
}
},
source : 'lastPoint' // Pointing to the new DataModel created
}])
Next let's take care of the text overlapping. Muze provides low level API through which you can get the relative position of different layers and arrange your other layers accordingly.
Since the line layer overlaps with the text layer, we need to get the reference of that line, for that we need to first give a name to line layer as follows:
{
mark: 'line',
name: 'lineLayer' // Give a name to the existing line layer
}
Muze Provides an API require by which we can get reference to any layer that we create in a canvas. It can be accessed as follows:
const require = muze.utils.require;
Then we can get the reference to the line layer and according arrange the position of the text layer as:
{
mark: 'text',
encoding: {
text: {
field: 'followers',
formatter: (val) => formatter(val)
}
},
source: 'lastPoint',
encodingTransform: require('layers', ['lineLayer', () => {
return (points, layer, dep) => {
const width = layer.measurement().width;
const height = layer.measurement().height;
const smartlabel = dep.smartLabel;
return points.map(point => {
const size = smartlabel.getOriSize(point.text);
if (point.update.y + size.height > height) {
point.update.y -= size.height / 2;
} else {
point.update.y += size.height / 2;
}
if (point.update.x + size.width / 2 > width) {
point.update.x -= size.width / 2 + 1;
}
return point;
})
}
}]),
}
Let's make our chart more appealing
To do this we will do the following changes:
- Format the axis labels to more readable form
- Remove the axis labels that are not required
For the first step, we need to define a formatter function that takes a raw value and change it to a more readable format:
const formatter = (val) => {
if (val > 1000000) {
return `${(val / 1000000).toFixed(2)} M`;
} else if (val > 1000) {
return `${(val / 1000).toFixed(2)} K`;
}
return val.toFixed(2);
};
Let's use this function in schema:
{
name: 'followers',
type: 'measure',
format: formatter,
displayName: 'Followers'
}
And let's use this function in text layer:
mark: 'text',
encoding: {
text: {
field: 'followers',
formatter: (val) => formatter(val) // formatter function
},
color: {
value: () => "#858585" // adding color
}
}
Then let's format the axis as below:
canvas.config({
border: {
showValueBorders: {
right: false, // remove axis borders
bottom: false
}
},
gridLines: {
y: {
show: false // remove gridlines
}
},
axes: {
y: {
tickFormat: (val, parsedVal, j, labels) => {
if (j === 0 || j === labels.length - 1) {
return (val) => {
if (val > 1000000) {
return `${(val / 1000000).toFixed(2)} M`
} else if (val > 1000) {
return `${(val / 1000).toFixed(2)} K`
} return val.toFixed(2);
}
} return '';
},
showAxisName: false
},
x: {
show: false. // hide x-axis
}
}
})
Also let's put the twitter username at the bottom. Muze supports HTML in its headers. We can take advantage of this as follows:
The html operator can be accessed as:
const html = muze.Operators.html;
.subtitle(html`<a href = "https://www.twitter.com/@${user}" class= "twitter-link" target="_blank">@${user}</a>`, { position: 'bottom', align: 'center' })
Let expand this to all the users
const { muze, getDataFromSearchQuery } = viz;
const data = getDataFromSearchQuery();
const dm = new DataModel(data);
const formatter = (val) => {
if (val > 1000000) {
return `${(val / 1000000).toFixed(2)} M`;
} else if (val > 1000) {
return `${(val / 1000).toFixed(2)} K`;
}
return val;
};
const users = [
"aplusk",
"barackobama",
"c_nyakundih",
"d_copperfield",
"elonmusk",
"hilaryr",
"hillaryclinton",
"iamcardib",
"jack",
"jashkenas",
"johnleguizamo",
"kathyireland",
"katyperry",
"kyliejenner",
"lucaspeterson",
"marthalanefox",
"michaeldell",
"nickconfessore",
"nickywhelan",
"nytimes",
"paulhollywood",
"paulkagame",
"poppy",
"porszag",
"rambodonkeykong",
"realalexjones",
"realdonaldtrump",
"samiralrifai",
"seanhannity",
"senjohnmccain",
"taylorswift13",
"twitter",
];
const canvases = [];
const div = document.createElement("div");
div.className = "chart-header muze-header-cell";
div.innerHTML =
"Charting the Great Twitter Bot Purge of 2018 (A Trellis Example)";
document.getElementById("chart").appendChild(div);
users.forEach((user, i) => {
const newDomNode = document.createElement("div");
newDomNode.className = "chart-div";
newDomNode.id = `chart${i + 1}`;
newDomNode.style.overflow = "auto";
document.getElementById("chart").appendChild(newDomNode);
const canvas = muze
.canvas()
.rows([[], ["followers"]])
.columns(["time"])
.transform({
lastPoint: (ds) => {
return ds.select({
field: "time",
value: ds.getField("time").domain()[1],
operator: DataModel.ComparisonOperators.EQUAL,
});
},
})
.layers([
{
mark: "line",
name: "lineLayer",
},
{
mark: "point",
source: "lastPoint",
},
{
mark: "text",
encoding: {
text: {
field: "followers",
formatter: (val) => formatter(val.rawValue),
},
color: {
value: () => "#858585",
},
},
encodingTransform: (points, layer, dep) => {
const width = layer.measurement().width;
const height = layer.measurement().height;
const smartlabel = dep.smartLabel;
return points.map((point) => {
const size = smartlabel.getOriSize(point.text);
if (point.update.y + size.height > height) {
point.update.y -= size.height / 2;
} else {
point.update.y += size.height / 2;
}
if (point.update.x + size.width / 2 > width) {
point.update.x -= size.width / 2 + 1;
}
return point;
});
},
source: "lastPoint",
},
])
.config({
columns: {
headers: {
show: false,
},
},
border: {
showValueBorders: {
right: false,
bottom: false,
},
},
gridLines: {
y: {
show: false,
},
},
axes: {
y: {
tickFormat: (dataInfo, contextInfo) => {
const length = contextInfo.allRawTicks.length;
if (
contextInfo.allRawTicks[0] ||
contextInfo.allRawTicks[length - 1]
) {
return formatter(dataInfo.rawValue);
}
return "";
},
show: false,
showAxisName: false,
},
x: {
show: false,
showAxisName: false,
},
},
})
.subtitle(`${user}`, { position: "bottom", align: "center" })
.data(rootData.select({ field: "user", value: user, operator: "eq" }))
.width(250)
.height(200)
.mount(`#chart${i + 1}`);
canvases.push(canvas);
});